Skip to content

feat(core): add three sandbox backends and move SandboxFileNotFoundError onto the contract - #12

Merged
amondnet merged 14 commits into
mainfrom
amondnet/local-sandbox
Aug 28, 2026
Merged

feat(core): add three sandbox backends and move SandboxFileNotFoundError onto the contract#12
amondnet merged 14 commits into
mainfrom
amondnet/local-sandbox

Conversation

@amondnet

@amondnet amondnet commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds three backends for the sandbox contract — @pleaseai/core/sandbox/local, @pleaseai/core/sandbox/just-bash and @pleaseai/core/sandbox/microsandbox — and moves SandboxFileNotFoundError onto the contract so one catch works across all four.

The three are siblings of @pleaseai/core/sandbox/docker and each is a different trade against it: local drops the daemon and the isolation, just-bash drops the host process and the real binaries, microsandbox keeps the isolation and moves it from the namespace to the hypervisor. Each subpath is a separate entry point, so importing @pleaseai/core pulls none of them in.

local — a host process, no daemon

The local backend's opposite trade with Docker is: no daemon, no image pull, no container — and no isolation. It exists for the cases where no daemon is reachable: a test suite that must run where Docker does not, a laptop working on the repository it is already inside, CI without a docker-in-docker rig.

The contract's durability requirement is what shapes it. getProcess(id) and logs({ replay: true }) are read after a process exits, frequently by a host process that never started it, so commands are journalled to disk rather than held as child handles.

Two pieces of the Docker wrapper could not come along, and both replacements were measured on darwin rather than assumed:

  • macOS ships no setsid. Replaced by Bun.spawn({ detached: true }), which already makes the wrapper a process-group leader — verified by pgid separation and by a grandchild dying with the group.
  • BSD tail has no --pid. Replaced by TypeScript file-offset polling that drains once more after the process stops.

The wrapper script is a constant: the journal directory, the timeout and the argv travel beside it as positional parameters, so no quoting step sits between a caller's SandboxCommand and execve. The Docker backend needs shell-quote.ts because its wrapper travels as one sh -c string; here it does not.

Two policy decisions, both stated where they are made:

  • The environment is allowlisted down from process.env (modelled on flue's DEFAULT_LOCAL_ENV_ALLOWLIST), so the agent's bash tool does not inherit every credential the developer is carrying.
  • IS_SANDBOX is deliberately not declared — the inverse of the Docker backend. There the claim is true; on the host it would be false, and the root check it defeats is the last thing between a bypassed permission prompt and the developer's own home directory.

A sandbox id resolves to a backend-owned directory (work/ + journal/) under a caller-named root, which is what makes destroy() safe to write at all: it only ever deletes a path the backend derived, never one a caller handed in.

just-bash — a virtual shell, no host process at all

just-bash is a bash interpreter in TypeScript over an in-memory filesystem, so a command here is parsed and evaluated rather than executed. It is an optional peer dependency, imported dynamically, so its absence is an actionable install instruction rather than a module resolution failure from inside a workflow step.

Four limits are structural, not gaps to be filled later. Each is pinned by a test, and each is a reason to choose a different backend rather than something this one can grow:

  • No real binaries. git --version exits 127. The command set is the interpreter's own, so a turn that runs a package manager, a compiler or git cannot run here at all.
  • No ports. Nothing in the sandbox ever listens on a socket, so portEndpoint raises JustBashPortsUnavailableError rather than answering http://127.0.0.1:<port> — a URL that would dial the host, which is both wrong and dangerous.
  • No live output. The interpreter coalesces a command's output into one message per stream and delivers it on completion, so logs({ follow: true }) returns everything at the end rather than as it happens.
  • No bytes that are not valid UTF-8. The filesystem stores text: four bytes written as base64 read back as eight, and wc -c inside the sandbox agreed. JustBashBinaryUnsupportedError refuses the write instead of corrupting it.

Two vendor behaviours needed accommodating:

  • runCommand({ env }) is accepted and never applied — a command run with env: { PROBE: 'x' } sees $PROBE empty. Per-exec env therefore goes through a constant wrapper script, with names, values and argv all passed as positional parameters. No interpolation, the same discipline as the local backend's journal wrapper.
  • The vendor's defence-in-depth layer patches Module._resolveFilename, which fails under Bun and kills the first command rather than the constructor. It defaults to the runtime's support and stays caller-overridable.

timedOut comes from a timer this backend owns, because the interpreter reports every cancellation as exit code 124.

microsandbox — isolation by hypervisor

Each sandbox is a real kernel booted from an OCI image, so the isolation is a hypervisor boundary rather than a namespace one. Also an optional peer dependency, imported dynamically.

Verification status, stated plainly. microsandbox ships no native addon for darwin-x64, the platform this was written on — import('microsandbox') throws unsupported platform darwin-x64. The behavioural suite (27 tests) has therefore never been observed to pass: it gates on isMicrosandboxAvailable() and skips, the same shape the Docker suite uses for an unreachable daemon.

What is verified everywhere:

  • test/sandbox/microsandbox/vendor-shape.test.ts asserts through tsc that the vendor's own declarations still satisfy this package's structural copies of them. (The copies exist so microsandbox stays out of the public .d.ts — a consumer who never installed the optional peer must still type-check.) That test earned its place immediately by rejecting two wrong models of the exec-options builder: a plain subset interface fails because a callback parameter is checked contravariantly, and a generic method fails because a concrete vendor signature cannot satisfy a universally quantified one. The builder had to become a type parameter on the interface. Drift detection was then demonstrated by adding a method to the copy and watching tsc reject it.
  • test/sandbox/microsandbox/provider.test.ts (20 tests, runs everywhere) covers name-collision separation, the 128-byte name limit, IS_SANDBOX, and the port refusal.

Three design decisions worth naming for reviewers:

  • The process journal is ../docker/journal.ts imported, not copied. That module contains no Docker — it is the POSIX shell every guest-side backend needs to make a process outlive the call that started it — and a second copy of 200 lines of signal-handling shell would drift. A shared home is the obvious refactor once a third guest backend appears.
  • The vendor's ExecHandle is deliberately not held by a process handle. It would answer wait()/kill() directly, but only for the host process that started the command — which is exactly the case the contract's durability requirement excludes.
  • portEndpoint answers from the caller's own guest-to-host map and raises MicrosandboxPortNotMappedError for anything else, rather than returning a URL that would dial the host. The vendor's own logStream was considered and rejected: its entries are correlated by a sessionId that ExecHandle never exposes.

The contract change

SandboxFileNotFoundError moved onto the contract because both backends had declared a class of the same name — the one shape that satisfies every behavioural test and still fails the thing the class is for. instanceof answered no across them, so a caller handed either backend could not write one catch. Every subpath still exports the symbol, so no caller's import changes. harness/files.ts is untouched: it decides absence by re-checking exists, not by matching an error type.

Reviewer guidance

One entry point per backend:

  • packages/core/src/sandbox/local/journal.ts — the wrapper script and why it differs from Docker's; local/provider.ts for the two policy decisions (env allowlist, no IS_SANDBOX).
  • packages/core/src/sandbox/just-bash/provider.ts — the structural limits, stated where they are decided; just-bash/runtime.ts for the two vendor accommodations.
  • packages/core/src/sandbox/microsandbox/runtime.ts — the verification status in full, and the structural type copies the tsc test guards.

Test coverage worth naming:

  • Cross-host-process durability (local) — a second provider sharing no memory reads a finished process's whole transcript back.
  • Process-group kill (local) verified against the host, not simulated, plus timeout escalation for a command that runs trap '' TERM.
  • Each just-bash limit has a test, so a future vendor release that lifts one will show up as a failing assertion rather than a silent behaviour change.
  • packages/core/test/sandbox/microsandbox/vendor-shape.test.ts — the only microsandbox coverage that runs on this platform; it type-checks rather than executes.
  • packages/core/test/sandbox/contract/errors.test.ts compares the exports themselves rather than any behaviour — a backend that re-declared its own error class would pass every other test in the suite. It now covers four backends.

Not in this PR

  • The local backend provides no isolation and must never be chosen for untrusted code. That is stated in its module docs and in both READMEs; it is not enforced in code, and this PR does not attempt to enforce it.
  • The microsandbox backend is not claimed to run. Its behavioural suite skips on this platform, and the READMEs say so rather than implying coverage it does not have.
  • The journal shared by the Docker and microsandbox backends stays in sandbox/docker/. Moving it to a neutral home is the refactor a third guest-side backend should trigger, not this PR.

Related issue

None. This branch is not linked to an issue, and the one open issue (#8, demux backpressure) is unrelated.

Checklist

  • PR title follows Conventional Commits
  • Tests added or updated, and the suite passes (bun run test) — 315 pass, 27 skip (the microsandbox behavioural suite, which cannot load its addon on darwin-x64), 0 fail
  • Lint and type-check pass (bun run lint, bun run type-check) — mise run ci green (lint + type-check + test + build)
  • Documentation updated if behavior changed — README.md and README.ko.md (subpath table + layout tree, plus a paragraph stating microsandbox is the one backend not yet observed to run), docs/project-layout.md (IS_SANDBOX notes for both new backends)
  • No breaking change, or a BREAKING CHANGE: note is included — every subpath still exports SandboxFileNotFoundError, so no import changes

Summary by cubic

Adds three sibling sandbox backends to the Docker one and moves SandboxFileNotFoundError onto the contract so one catch works across all of them. local runs host processes with no isolation, just-bash is a virtual shell with no host process, and microsandbox is hypervisor-isolated but not yet verified against a live runtime. The merge with main brings the branch onto the defineAgent/defineSandbox API and the @pleasedev scope; the new backends ship no SandboxBackendFactory yet, which is filed as a follow-up.

Backends

  • The local backend journals commands to disk, so processes and replay logs survive the spawning process exiting.
  • just-bash and microsandbox are optional peer dependencies imported dynamically; the microsandbox suite gates on a probe boot raced against BOOT_TIMEOUT_MS, so a missing addon (darwin-x64), a failed boot, or a hang all skip.
  • microsandbox imports the Docker journal rather than copying it; all subpaths re-export the contract error, so no caller import changes.
  • microsandbox's six previously-unexecuted modules are now exercised through a stand-in runtime backed by a real container, raising their coverage from 2-10% to 80-99%.
  • The READMEs resolve main's rewritten status section with the new backends' coverage note appended.

Bug Fixes

  • microsandbox exec no longer blocks until the command finishes; the wrapper is launched backgrounded inside the guest.
  • The just-bash timeout timer no longer marks finished commands as timed out or keeps the host process alive, and its process registry now lives on the provider.
  • The microsandbox peer range is pinned to ^0.6 to match the type copies.
  • Local teardown kills journalled process groups before deleting the tree and holds the escalation exit record until the group is gone; sandbox ids and VM names digest with SHA-256 so a collision cannot share a directory or microVM.
  • The journal watchdog's stand-down uses two portable traps, so under dash a stand-down no longer leaks its sleep.
  • Local getProcess rejects ids the backend never minted, and a wrapper's pid is checked against its own argv before alive is believed.
  • microsandbox narrows read errors to SandboxFileNotFoundError only when the file is absent; just-bash evicts a dying handle before teardown and reports "not installed" only for a resolution failure naming the package.
  • microsandbox destroy reads the in-flight teardown at call time and gates every handle on it, so a handle built during a quiet moment cannot adopt a VM a later destroy is removing; the stand-in runtime also enforces stream timeouts it previously dropped and clears their budget timer on child exit.

Written for commit e638053. Summary will update on new commits.

`@pleaseai/core/sandbox/local` implements the sandbox contract by running real
processes on the host, as the sibling of the Docker backend and its opposite
trade: no daemon, no image pull, no container — and no isolation.

The contract's durability requirement is what shapes it. `getProcess(id)` and
`logs({ replay: true })` are read after a process exits, frequently by a host
process that never started it, so commands are journalled to disk rather than
held as child handles. Two pieces of the Docker wrapper could not come along:
macOS ships no `setsid` and its `tail` has no `--pid`. Measured replacements —
`Bun.spawn({ detached: true })` already makes the wrapper a process-group
leader, and a followed read polls file offsets instead of shelling out.

The wrapper is also a constant: the journal directory, the timeout and the argv
travel beside it as positional parameters, so no quoting step stands between a
caller's `SandboxCommand` and `execve`.

Two policy decisions, both stated where they are made:

- the environment is allowlisted down from `process.env` (flue's
  `DEFAULT_LOCAL_ENV_ALLOWLIST`), so the agent's bash tool does not inherit
  every credential the developer is carrying;
- `IS_SANDBOX` is deliberately *not* declared, the inverse of the Docker
  backend. There the claim is true; here it would be false, and the root check
  it defeats is the last thing between a bypassed permission prompt and the
  developer's own home directory.

A sandbox id resolves to a backend-owned directory (`work/` + `journal/`) under
a caller-named root, which is what makes `destroy()` safe to write at all — it
only ever deletes a path this backend derived, never one a caller handed in.
Both backends declared a class of the same name, which is the one shape that
satisfies every behavioural test and still fails the thing the class is for:
`instanceof` answers no across them, so a caller handed either backend could
not write one `catch`. That is the same argument the contract already makes for
`SandboxWaitTimeoutError` — a shared identity to test against, because a
string-matched message is not one — so the class belongs where those live.

Both subpaths keep exporting the symbol, now sourced from the contract, so no
caller's import changes. `../harness/files.ts` is untouched: it decides absence
by re-checking `exists` rather than by matching an error type, which is what
lets it answer `null` for a path that vanished between the two calls.

The stale "the one runtime value this package exports" note on
`SandboxWaitTimeoutError` is corrected — there were already two.

Pinned by `test/sandbox/contract/errors.test.ts`, which compares the exports
themselves rather than any behaviour: a backend that re-declared its own class
would pass every other test in the suite.
All three were verified against a live sandbox before and after, and each is now
pinned by a test that was confirmed to fail on the unfixed code.

**A failed spawn left a permanent phantom.** The abandon-marker guard wrapped
only the start confirmation, not the spawn. `Bun.spawn` throws outright for a
`cwd` the host does not have, and `prepareJournal` has already written the
journal by then — so every later `listProcesses()` reported that id as a process
stuck in the `error` state, which is exactly what the marker exists to prevent.

**Hardcoded signal numbers were Linux's.** `SIGUSR1` is 10 on Linux and 30 on
macOS, so the wrapper recorded 10 while the shell exited with 158, the two were
found to disagree, and a signalled process was reported as having merely
returned a large code. In a file whose whole preamble is about macOS
portability. The numbers now come from the host's own table, and `SIGTERM` /
`SIGKILL` with them, so the record and `128 + n` stay locked together.

**The timeout watchdog leaked its `sleep`.** Standing the watchdog down killed
the subshell but not the `sleep` it had forked, which then ran out the full
budget — hour-scale, for the timeout a long turn is given — after the command
had already exited. The nap is now the watchdog's own child, reaped by a TERM
disposition.

Two further findings were reported and are fixed here as documentation rather
than code, because in both cases the code was right and a comment was not:
`readFile`'s note claimed a rejection *type* the contract only says a backend
SHOULD use, which contradicted `harness/files.ts`'s reason for re-checking
`exists`; and `MAX_CHUNK_BYTES` read as though it bounded the queue, when the
pump enqueues without consulting `desiredSize`.

One finding the review left as a design call is fixed too, because it has a
repair that needs no new contract state: discovery is now gated on the wrapper
having actually launched — a pid, or an exit — rather than on the journal
directory existing. `prepareJournal` writes `meta` from the host before the
spawn, so a `listProcesses()` racing an in-flight `exec()` saw a healthy
process as failed. Not yet listed is the honest answer for that window; `exec`
has not returned a handle for it either.

Still open, and deliberately not repaired here: the log stream applies no
backpressure. Fixing it means driving the pump from `pull`, which changes when
it runs, and that is a bigger change than this commit should carry.
@amondnet
amondnet marked this pull request as ready for review August 28, 2026 07:20

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 26 files

Architecture diagram
sequenceDiagram
    participant Caller as Sandbox Caller
    participant Provider as Local Sandbox Provider
    participant Session as Local Session
    participant Root as Sandbox Root
    participant Journal as Journal (Disk)
    participant Wrapper as Wrapper Script
    participant Command as Sandboxed Command
    participant Files as File System API

    Note over Caller,Files: Local Host-Process Sandbox Backend

    Caller->>Provider: createLocalSandbox({ root, env })
    Provider->>Provider: resolveBaseEnv(env)
    Note over Provider: Allowlist process.env<br/>No IS_SANDBOX declared

    Caller->>Provider: session(sandboxId)
    Provider->>Root: createSandboxRoot(sandboxId, root)
    Root->>Root: Derive sanitized dirName + digest
    Provider-->>Caller: SandboxSession

    Caller->>Session: exec(command, { timeout })
    Session->>Root: ready()
    Root->>Journal: mkdir(work/, journal/)
    Session->>Journal: prepareJournal(paths, meta)
    Note over Journal: Write meta, empty out/err files
    Session->>Wrapper: Bun.spawn({ detached: true })
    Note over Wrapper: POSIX shell wrapper<br/>Leads its own process group
    
    alt Spawn fails (bad cwd, etc.)
        Session->>Journal: Write abandon marker
        Session-->>Caller: Throw start error
    else Spawn succeeds
        Wrapper->>Journal: Write pid
        Wrapper->>Wrapper: Clear signal traps in child
        Wrapper->>Command: exec "$@" (argv as-is, no quoting)
        Command->>Journal: stdout/stderr redirected to files
    end

    alt Timeout configured
        Wrapper->>Wrapper: Start watchdog (subshell + sleep)
        Note over Wrapper: Watchdog fires on budget expiry
        Wrapper->>Journal: Touch timeout marker
        Wrapper->>Wrapper: kill -TERM -$wrapper (process group)
        Wrapper->>Journal: Signal handler writes signal number
        opt Escalation needed (trap '' TERM)
            Wrapper->>Wrapper: Escalator sleep 3s
            Wrapper->>Journal: Write SIGKILL exit code
            Wrapper->>Wrapper: kill -9 -$wrapper
        end
    end

    Wrapper->>Journal: Write exit code
    Wrapper-->>Session: Process ends
    Session-->>Caller: SandboxProcessHandle

    Caller->>Session: getProcess(processId)
    Session->>Root: peek() (no creation)
    Session->>Journal: Read journal state
    alt Abandon marker present
        Session-->>Caller: null (process never launched)
    else No pid/exit recorded
        Session-->>Caller: null (not yet started)
    else Valid journal
        Session-->>Caller: Reconstructed handle
    end

    Caller->>Session: waitForExit()
    Session->>Journal: Poll exit file
    alt Process still running
        Session->>Journal: Continue polling
        alt Caller timeout/abort
            Session-->>Caller: SandboxWaitTimeoutError
        end
    else Process gone, no exit record
        Session-->>Caller: SandboxNoExitRecordError
    else Exit recorded
        Session-->>Caller: ProcessExit
    end

    Caller->>Session: logs({ replay: true, follow })
    Session->>Journal: Read out/err file offsets
    
    loop While process alive or new data
        Session->>Journal: Poll file sizes (offset-based)
        alt Only checkpoint / not alive
            Session-->>Caller: Terminal event (exit/error)
        else New data available
            Session-->>Caller: stdout/stderr events (64KB chunks)
        end
    end

    Caller->>Session: kill()
    alt Process alive
        Session->>Session: process.kill(-pid, signal)
        Note over Session: Targets whole process group
        Session->>Journal: Poll for exit record
    else Already exited
        Session-->>Caller: No-op (pid is historical)
    end

    Caller->>Session: destroy()
    Session->>Root: remove()
    Root->>Files: rm(root/dirName, recursive, force)
    Root-->>Session: Deleted
    Provider->>Provider: Clear cached root (if identity matches)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/sandbox/local/process-state.ts Outdated
Comment thread packages/core/src/sandbox/local/process.ts
Comment thread packages/core/src/sandbox/local/root.ts
Comment thread packages/core/src/sandbox/local/root.ts Outdated
Comment thread packages/core/src/sandbox/local/journal.ts
Comment thread packages/core/src/sandbox/local/root.ts Outdated
Comment thread packages/core/test/sandbox/local/backend.test.ts Outdated
Comment thread packages/core/src/sandbox/local/session.ts
Comment thread packages/core/src/sandbox/local/env.ts Outdated
The third backend, and the first that runs no process at all: `just-bash`
interprets commands over an in-memory filesystem, so this one needs no daemon,
no image and no host process. It is reached at
`@pleaseai/core/sandbox/just-bash`, with `just-bash` as an optional peer
dependency imported dynamically — a caller on the docker or local backend never
resolves it.

Four limits are structural rather than gaps, and each has a test that pins it:

- no real binaries — `git --version` exits 127;
- no ports — `portEndpoint` raises `JustBashPortsUnavailableError` rather than
  answering a URL that would dial the host;
- no live output — the interpreter coalesces a command's output into one message
  per stream and delivers it on completion;
- no bytes that are not valid UTF-8 — the filesystem stores text, measured: four
  bytes written as base64 read back as eight, so a write that could only be
  stored corrupted raises `JustBashBinaryUnsupportedError` instead.

Two vendor behaviours needed accommodating. `runCommand`'s `env` is accepted and
never applied, so per-exec env goes through a constant wrapper script with names,
values and argv all passed as positional parameters — no interpolation, the same
discipline as the local backend's journal wrapper. And defence-in-depth patches
`Module._resolveFilename`, which fails under Bun and kills the first command
rather than the constructor, so it defaults to the runtime's support and stays
caller-overridable.

`SandboxWaitTimeoutError` and `timedOut` come from a timer this backend owns:
the interpreter reports every cancellation as exit code 124, so the exit code
cannot tell a caller's kill from an expired budget.
@socket-security

socket-security Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedjust-bash@​3.4.2891009896100
Addedmicrosandbox@​0.6.159010010097100

View full report

@socket-security

socket-security Bot commented Aug 28, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm strtok3 is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: packages/core/package.jsonnpm/just-bash@3.4.2npm/strtok3@10.3.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/strtok3@10.3.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 19 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread packages/core/src/sandbox/just-bash/process.ts Outdated
Comment thread packages/core/src/sandbox/just-bash/session.ts Outdated
Comment thread packages/core/src/sandbox/just-bash/runtime.ts
Comment thread packages/core/src/sandbox/just-bash/session.ts
The fourth backend, and the only one whose isolation is a hypervisor rather
than a namespace or an interpreter: each sandbox is a real kernel booted from
an OCI image, so a guest escape is not a host compromise. Reached at
`@pleaseai/core/sandbox/microsandbox`, with `microsandbox` as an optional peer
dependency imported dynamically.

**Verification status, stated plainly rather than implied.** `microsandbox`
ships no native addon for `darwin-x64`, which is the host this was written on —
`import('microsandbox')` there throws `unsupported platform darwin-x64`. So the
behavioural suite has never been observed to pass: it gates on
`isMicrosandboxAvailable()` and skips, the same shape the Docker suite uses for
an unreachable daemon. Comments in the backend mark what is a decision versus
what the vendor documents; none reports a measurement that was not taken.

What *is* verified everywhere is the part most likely to rot. The vendor's types
are copied structurally so that `microsandbox` stays out of this package's
public `.d.ts`, and `vendor-shape.test.ts` asserts — through `tsc` — that the
vendor's own declarations still satisfy those copies. It earned its place
immediately by rejecting two wrong models of the exec-options builder: a plain
subset interface fails because a callback parameter is checked contravariantly,
and a generic method fails because a concrete vendor signature cannot satisfy a
universally quantified one. The builder is a type parameter on the interface,
which is the one shape that holds.

The process journal is `../docker/journal.ts`, imported rather than copied. That
module contains no Docker — it builds the POSIX shell every guest-side backend
needs to make a process outlive the call that started it — and a second copy of
two hundred lines of signal-handling shell would drift. Reading it differs,
which is why `process-state.ts` is this backend's own: the five shell-answerable
facts come from one script per poll, while `meta` is read through the vendor's
filesystem channel, where caller-supplied argv needs no separator to survive.

Two decisions worth naming. The vendor's `ExecHandle` is deliberately not held
by a process handle: it would answer `wait()` and `kill()` directly, but only
for the host process that started the command, which is exactly the case the
contract's durability requirement excludes. And `portEndpoint` answers from the
caller's own guest-to-host map, raising `MicrosandboxPortNotMappedError` for
anything else rather than returning a URL that would dial the host.
@amondnet amondnet changed the title feat(core): add a host-process sandbox backend that needs no daemon feat(core): add three sandbox backends and move SandboxFileNotFoundError onto the contract Aug 28, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 20 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/sandbox/microsandbox/session.ts Outdated
Comment thread packages/core/src/sandbox/microsandbox/files.ts Outdated
Comment thread packages/core/src/sandbox/microsandbox/provider.ts Outdated
Comment thread packages/core/src/sandbox/microsandbox/process.ts Outdated
Comment thread packages/core/package.json Outdated
Four came from a review pass over the branch, one from measuring the fix for
another. All five have a regression test where one can exist.

**microsandbox `exec` blocked for the process's entire lifetime.** The Docker
backend launches the journal wrapper with `docker exec --detach`; the copy here
dropped it, and the vendor has no equivalent — `execWith` resolves only once the
command has finished. Since the wrapper ends in `exec setsid --wait`, awaiting it
meant `exec()` returned only after the process it hands back a live handle to had
already exited, and `waitForExit({ timeout })` could never be reached to bound
anything. The launch is now backgrounded inside the guest instead; the wrapper
survives the launching shell because it runs under `setsid`. The shell semantics
are verified — the launcher returns in milliseconds, the child outlives it, and
nested quoting round-trips — but whether the runtime reaps the exec's tree is
not, like the rest of that backend.

**A stale just-bash timeout timer retroactively marked a finished command as
timed out.** The timer set `expired` unconditionally, and only `waitForExit`
stood it down, so a caller that read `status()` instead saw `timedOut: true` on a
command that had exited 0 well inside its budget. It is now armed after the
command exists and returns early if the command has already exited.

**The same timer kept the host process alive.** Measured after the fix above: a
30s budget nothing awaited held the process open for the full 30s, and an agent
turn's budget is hours. It is unreferenced now, asserted from a child process
because the claim is about the event loop rather than a value.

**The just-bash process registry was per-session-object, not per-provider.**
`SandboxProvider.session` is called per use by contract, so a `getProcess` after
a second `session()` over the same id answered `null` — contradicting the file's
own header. The registry moved to the provider, and is dropped with the handle on
`destroy`. Every existing test reused one session object, which is why nothing
caught it.

**The `microsandbox` peer range was empty**, meaning any version, while the type
copies target 0.6 and the devDependency pins `^0.6.15`.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/sandbox/just-bash/provider.ts Outdated
…iew found

Applies the cubic review on #12. The findings that changed behaviour rather
than wording:

- local: a wrapper's pid is confirmed against its own argv before `alive` is
  believed. A wrapper SIGKILLed from outside writes no exit record, so `alive`
  is the only signal left — and a reused pid made `status()` report `running`
  forever and `kill()` aim a group signal at an unrelated tree.
- local: `sandboxDirName` and microsandbox's `sandboxName` digest with SHA-256
  rather than 32-bit FNV-1a. A digest collision means two sandboxes sharing one
  directory or one microVM, so one id's `destroy()` deletes the other's data.
- local: `remove()` kills the process groups the journal still reports running
  before deleting the tree. Unlinking a directory does not stop a detached
  process writing into it.
- local: `ready()` waits out an in-flight `remove()`, so an `exec()` racing a
  destroy cannot recreate the tree inside the `rm` and report itself ready over
  a directory that no longer exists.
- local: `waitForExit` holds the escalation's exit record back until the group
  it describes is actually gone. That record is written before `kill -9`, by
  necessity, and returning there let a caller tear down over live descendants.
- local: `getProcess` rejects an id that is not one this backend could have
  minted, so a path-shaped id reads nothing rather than reading outside the
  journal tree.
- local: the watchdog's TERM trap kills `jobs -p` rather than `$nap`, closing
  the window between starting the sleep and assigning its pid.
- just-bash: an aborted `logs()` returns at abort time. The vendor's `logs()`
  buffers until the command ends, so the old post-await check made an aborted
  read wait for exactly the command it gave up on.
- just-bash: `loadJustBash` reports "not installed" only for a resolution
  failure naming the package; a module-evaluation error or a missing transitive
  dependency now propagates as itself.
- just-bash: `session().destroy()` evicts its handle before awaiting teardown,
  so a concurrent `session(id)` cannot adopt a dying handle and have it deleted
  underneath it.
- microsandbox: log offsets come from `fs().stat` gated on `fs().exists`, so a
  failed measurement is no longer reported as offset zero — which told a follow
  read to replay the whole log.
- microsandbox: a failed read is only reported as `SandboxFileNotFoundError`
  once `exists` says the file is absent; anything else rethrows the vendor's
  own error.
- microsandbox: the published-port map is copied rather than aliased.

Two regression tests cover the reap on destroy and the rejected process id, and
the cursor-resume test polls instead of racing a fixed 200ms nap against a 0.6s
gap.
Resolves the two README conflicts: main renamed the npm scope to `@pleasedev`
while this branch added three subpath rows to the same table, so the resolution
keeps all six rows under the new scope.

The rename is carried into the code this branch adds — the `local`, `just-bash`
and `microsandbox` entry points and runtimes, and `docs/project-layout.md` —
and into the two harness tests that assert the `pleasedev-` provider-id prefix.
The `@pleaseai/...` names left in `contract/`, `docker/journal.ts` and
`harness/index.ts` are the packages this code was vendored from, not this
package, which is why main left them alone too.
…x on a boot

Both failures came from the first CI run to exercise this branch on Linux.

The watchdog's TERM trap was changed to `kill -9 $(jobs -p)` on the previous
commit, to close the window between starting the nap and assigning its pid.
That closed it on bash and opened a wider one everywhere else: a command
substitution runs in a subshell, which does not inherit the job table, so under
dash — Debian and Ubuntu's `/bin/sh` — it expands to nothing and every
stand-down leaks its nap. CI caught it as a `sleep 987.654` left in the process
table.

The stand-down is now two traps: the first only records that a TERM arrived,
because exiting is what strands the nap; the second replaces it once the pid is
in hand, and the check between them catches a stand-down that landed while the
first was installed. Every ordering is covered, and nothing outside POSIX is
used. Verified against dash and bash in a Debian container, alongside the
pre-change script for comparison.

The microsandbox suite gated on `isMicrosandboxAvailable()`, which answers
whether the runtime imports. On `darwin-x64` that is the whole answer, since
there is no native addon; on the GitHub-hosted runner the addon loads and the
guest then dies with SIGABRT before its agent relay comes up, for want of a
hypervisor — so the suite ran and failed on every push. The gate now boots one
throwaway sandbox and asks the host directly, which costs an extra boot where
the answer is yes and is the only form of the question that does not guess at
what the runtime needs underneath it.
Takes in `defineAgent` / `defineSandbox` (#13) and the dev-TUI boot chrome
(#15). Both READMEs conflicted where main rewrote the Status section around the
new public API while this branch added three backend rows and a note on where
each backend's suite actually runs; the resolution keeps main's narrative and
appends the coverage note, and folds the three new backend directories into
main's deeper `src/agent` + `src/sandbox` layout tree.

The three backends this branch adds ship no `SandboxBackendFactory` — the
`docker({ image })` shape main introduced — because the adaptation is not
mechanical for any of them: microsandbox takes ports as a guest→host map rather
than a list, and just-bash publishes no ports at all, which `resolveSandbox`
requires. Filed as a follow-up rather than decided in a merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/test/sandbox/microsandbox/backend.test.ts Outdated
The microsandbox backend was type-checked and never executed: it ships no
native addon for darwin-x64 and needs a hypervisor the CI runner does not
provide, so its integration suite skipped everywhere and six modules —
session, process, process-state, process-logs, guest, files — had no line of
coverage at all. Patch coverage came in at 65% against the repository's own 80%
target, and the missing lines were almost entirely those six.

The backend is written against structural copies of the vendor's types, which
means it can be handed a different implementation of them. `test/sandbox/
microsandbox/fake-runtime.ts` is one: a `MicroSandbox` whose execs go to a real
`execve` in a container and whose `fs()` reads and writes real files. It is a
stand-in for the runtime, not a mock of the backend — nothing in it knows what
the code under test intends to run, so a `sleep 30` really outlives the call
that started it and a group kill really has to reach a grandchild. The
assertions are the ones the real-microVM suite makes, kept identical so a
change that breaks the backend breaks both. What it cannot stand in for —
hypervisor isolation and the napi transport — is named rather than implied.

Coverage on those six modules goes from 2-10% to 80-99%. Also covered, having
been added by this PR and exercised by nothing: the local backend's SIGKILL
escalation path (a command that ignores the timeout's SIGTERM), a log stream
closing on `error` for a process that journalled no exit, and just-bash's
aborted log read in both its forms.

One fix rather than a test: microsandbox's provider had the same destroy race
cubic found in just-bash's, evicting its cached handle in a `finally` after the
teardown instead of before it, where a concurrent `session(id)` can adopt the
dying handle and have it evicted underneath it.
…ery other boot gets

The probe is a top-level `await`, which sits outside every per-test budget bun
applies. A boot that fails is already handled; a boot that *hangs* — a stalled
pull, or a hypervisor that deadlocks rather than aborting — would hang the whole
file with no test to attribute it to. Racing it against `BOOT_TIMEOUT_MS` makes
a hang the same answer as a rejection: this host cannot run the suite. The
teardown is bounded for the same reason, and the timer is unreferenced so
winning the race does not hold the process open for five minutes.

Raised by cubic-dev-ai on #12.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/sandbox/microsandbox/provider.ts
Comment thread packages/core/test/sandbox/microsandbox/fake-runtime.ts Outdated
… it would race

Evicting the cached handle before awaiting the teardown fixed one race and
opened another. A sandbox id resolves to a sandbox *name*, and acquiring a name
adopts whatever the runtime's database already has under it — the property that
makes an id resumable across host processes. So the fresh handle a concurrent
`session(id)` builds is not independent of the one being torn down: it adopts
the very VM that destroy is removing, and is left holding a machine about to be
killed underneath it.

`destroy()` now publishes its teardown per sandbox id, and a handle built while
one is in flight has `ready`, `peek` and `remove` held behind it. The teardown's
failure is swallowed at the gate and still raised to the caller that asked for
the destroy: a caller merely queued behind it wants the timing, not the error,
and a failed teardown that poisoned every later session would turn one bad
destroy into a permanently unusable id. `local/root.ts` states the same rule for
a directory rather than a VM.

just-bash needs no equivalent — a new just-bash handle is an independent virtual
filesystem, not a second claim on the same named machine.

Also in the stand-in runtime: `execStreamWith` recorded a timeout and dropped
it, while `execWith` enforced one. Nothing in the backend sets a deadline on a
stream today, which is exactly what makes a silently ignored one a trap for the
caller that does — a `tail -f` given a budget would run until the file it
follows ends, which for a `follow` read is never.

Raised by cubic-dev-ai on #12.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/sandbox/microsandbox/provider.ts
Comment thread packages/core/test/sandbox/microsandbox/fake-runtime.ts Outdated
…onstruction

Capturing the in-flight teardown when the handle was built covered only the
teardown that already existed. One starting afterwards is exactly as dangerous —
a `ready()` on a handle built during a quiet moment could still adopt a VM a
later destroy is removing — and a second destroy replacing the first while an
acquire was suspended on it was covered only transitively.

The gate now reads the teardown map when it is called, in a loop, which is the
shape `ready()` in `local/root.ts` already had. Every handle is gated, since the
gate costs a map lookup and no longer depends on what was in flight at build
time. `remove` is deliberately left ungated: two teardowns for one name are
removing the same machine, which is idempotent rather than a race, and gating it
would make a destroy wait on the teardown it is itself about to publish.

What this still does not cover is an acquire already past the gate when a
destroy begins. Nothing short of a lock inside the handle would, and that is the
ordinary use-after-destroy any backend has — the contract does not promise a
session survives being torn down under it. Said in the source rather than left
to be rediscovered.

Also in the stand-in runtime: the streamed exec's budget timer is cleared when
the child exits, the way `runExec` already clears its own. Unreferenced, it held
nothing open, but it lingered for the rest of the budget with nothing left to do
but abort a controller no one was listening to.

Raised by cubic-dev-ai on #12.
@amondnet
amondnet added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 0a33a6b Aug 28, 2026
7 checks passed
@amondnet
amondnet deleted the amondnet/local-sandbox branch August 28, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant